Del dato al modelo

Piplines personalizados utilizando python

Karina A. Bartolomé

Especialista en Métodos Cuantitativos para la Gestión y Análisis de Datos en Organizaciones (FCE, UBA). Lic. en Economía (FCE, UNLP). Líder técnica de Ciencia de Datos (Ualá).


Organizadora: Natalia R. Salaberry
Doctora en la Universidad de Buenos Aires, Ciencias Económicas. Magister en Métodos Cuantitativos para la Gestión y Análisis de Datos en Organizaciones (FCE, UBA). Lic. en Economía, FCE, UBA. Investigadora en CIMBAGE (IADCOM), Docente de posgrados y Estadística I, FCE, UBA

CIMBAGE (IADCOM) - Facultad Ciencias Económicas (UBA)

2025-09-05

1 Planteo del caso

1.1 Prevención del fraude transaccional

Lectura de datos
import kagglehub
path = kagglehub.dataset_download("kartik2112/fraud-detection")
df_train = pd.read_csv(f"{path}/fraudTrain.csv")
df_test = pd.read_csv(f"{path}/fraudTest.csv")
df = pd.concat([df_train, df_test], axis=0, ignore_index=True).reset_index(drop=True)

target = 'is_fraud'
cols_selected = [
    "trans_date_trans_time",
    "merchant",
    "category",
    "amt",
    "city_pop",
    "job",
    "dob",
    "zip",
    "lat",
    "long",
    "merch_lat",
    "merch_long",
    "is_fraud",
]
df = df[cols_selected]

Se cuenta con un dataset de 1852394 transacciones de tarjetas de crédito. Son 13 variables generadas sintéticamente por lo que el foco está sobre cómo procesarlos y no sobre la performance del modelo.

No se cuenta con valores faltantes, por lo que se genera “ruido” en el dataset, añadiendo valores faltantes en distintas variables para el ejemplo.

Generador de ruido en el dataset
## Valores faltantes
def add_random_nans(values, fraction=0.2):
    """
    Generador de valores faltantes
    """
    np.random.seed(42)
    mask = np.random.rand(len(values)) < fraction
    new_values = values.copy()
    new_values[mask] = np.nan
    return new_values

df = df.assign(
    merchant = lambda x: [i.replace('fraud_','') for i in x['merchant']],
    dob = lambda x: add_random_nans(x['dob'], fraction=0.05),
    job = lambda x: add_random_nans(x['job'], fraction=0.1),
    city_pop = lambda x: add_random_nans(x['city_pop'], fraction=0.03),
    merch_lat = lambda x: add_random_nans(x['merch_lat'], fraction=0.02),
    merch_long = lambda x: add_random_nans(x['merch_long'], fraction=0.02),
)

Ante una nueva transacción, ¿cuál es la probabilidad de que sea fraudulenta? ¿Debería bloquarse?

Figura 1: Diagrama caso

La variable objetivo (target) es is_fraud , donde el porcentaje de observaciones de clase 1 (fraudulentos) es 0.52%.

\(P(\color{#FF9933}{is\_fraud}=1) = f(\color{#3399FF}{X})\)

\(\color{#FF9933}{is\_fraud}\): variable que puede tomar 2 valores: 1 (transacción fraudulenta) o 0 (transacción legítima)

\(\color{#3399FF}{X}\): matriz nxm, siendo n la cantidad de observaciones y m la cantidad de variables (o atributos)

Tabla 1: Datos transaccionales (muestra de 4 observaciones)
is_fraud trans_date_trans_time merchant category amt city_pop job dob zip lat long merch_lat merch_long
0 2019-08-29 02:46:55 Harris Inc gas_transport $67.94 302.0 Magazine features editor 1973-05-04 29939 32.68 -81.25 32.28 -81.21
1 2019-01-10 22:14:49 Kuhic, Bins and Pfeffer shopping_net $1,161.58 276002.0 Medical technical officer 1950-12-14 34120 26.33 -81.59 25.92 -82.5
0 2019-12-25 09:45:55 Kutch, Hermiston and Farrell gas_transport $53.81 2408.0 Sales professional, IT 1997-07-05 29438 32.55 -80.31 33.38 -81.0
0 2020-07-10 10:18:00 Kovacek, Dibbert and Ondricka grocery_pos $166.69 337.0 Occupational psychologist 1954-07-05 94971 38.24 -122.91 37.45 -122.52

Fuente de los datos: Credit Card Transactions Fraud Detection Dataset.

2 Esquema de modelado

2.1 Esquema de modelado

La Figura Figura 2 muestra un posible esquema de trabajo para modelos de aprendizaje automático en donde se busca predecir sobre datos nuevos.

Base de datos
Base de datos
Train
Train
Test
Test
Procesamiento
  • Casteo
  • Nuevas variables
  • Imputación de valores faltantes
  • Tratamiento de valores atípicos
  • Encoding de variables categóricas
  • Otras transformaciones
Procesamiento...
Modelado
  • Ajuste sobre datos de entrenamiento
Modelado...
Evaluación
  • Métricas en la partición de entrenamiento / Validación cruzada
  • Métricas en la partición de evaluación
  • Métricas en producción
Evaluación...
Prod
Prod
Deploy
  • Predicción sobre datos nuevos
Deploy...
Text is not SVG - cannot display
Figura 2: Esquema de modelado

2.2 Particiones

Partición en dataset de entrenamiento y evaluación.

  • Dataset de entrenamiento –> Ajuste del modelo
  • Dataset de evaluación –> Métricas


Generación de particiones
y = df[target]
X = df.drop([target], axis=1)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, shuffle=True, stratify=y, random_state=42
)

display(Markdown(f"N observaciones en entrenamiento: {X_train.shape[0]}"))
display(Markdown(f"N observaciones en evaluación: {X_test.shape[0]}"))

N observaciones en entrenamiento: 1296675

N observaciones en evaluación: 555719

3 Preprocesamiento

3.1 Tipos de transformaciones

Ciertos tipos de transformaciones requieren “aprender” algunos aspectos de los datos mientras que otras no.

Ejemplos de transformaciones que dependen de los datos de entrenamiento:

  • Imputación de valores faltantes con la mediana → La mediana depende de los datos

  • Escalado → Se debe calcular la media y desvío estándar de los datos

Ejemplos de transformaciones que no dependen de los datos de entrenamiento:

  • Construcción de una nueva variable mediante un cálculo simple → x^2

  • Combinaciones de variables en una nueva variable → x/y

3.2 Transformaciones en python, mediante scikit-learn

Para implementar este tipo de aprendizajes de ciertos aspectos de los datos al generar transformaciones custom, en scikit-learn se utiliza una clase específica TransformerMixin.

TransformerMixin
TransformerMixin
BaseEstimator
BaseEstimator
CustomTransformer
CustomTransformer
.fit()
.fit()
.transform()
.transform()
class CustomTransformer(BaseEstimator, TransformerMixin):

def __init__(self, variables=None):
self.variables = variables

def fit(self, X, y=None):
X_ = X.copy()
if self.variables is None:
self.variables = X_.columns.tolist()
self.promedios_ = X_[self.variables].mean()
return self

def transform(self, X):
X_ = X.copy()
for var in self.variables:
X_[var] = X_[var] / self.promedios_[var]
return X_
class CustomTransformer(BaseEstimator, TransformerMixin):...
Aprendizaje de aspectos de los datos de entrenamiento (fit): Promedio de cada variable
Aprendizaje de aspectos de lo...
Transformaciones sobre los datos (transform): 
Variable / Promedio
Transformaciones sobre los da...
Text is not SVG - cannot display
Figura 3: Diagrama transformers

3.3 Transformaciones iniciales

  • Cálculo de la edad

  • Cálculo de la distancia entre el comercio y el usuario

  • Generación de variables vinculadas a la fecha y hora de la transacción

Transformaciones iniciales
class TransformacionesIniciales(BaseEstimator, TransformerMixin):
    """
    Transformaciones iniciales del dataset. 
    """

    def __init__(self, age_features=True, timestamp_features=True, distance_features=True):
        """
        Args:
            timestamp_features (bool): Generar variables basadas en la fecha/hora de la trx
            distance_features (bool): Generar variables basadas en la distancia al comercio
        """
        self.age_features = age_features
        self.timestamp_features = distance_features
        self.distance_features = distance_features

    def fit(self, X, y=None):
        return self

    def transform(self, X, y=None):
        # Se genera una copia para no afectar al df original:
        X_ = X.copy()

        # Cast features:
        X_ = (X_
            .assign(
                dob = lambda x: pd.to_datetime(x['dob'], errors='coerce'),
                trans_date_trans_time = lambda x: pd.to_datetime(x["trans_date_trans_time"], errors="coerce"),
            )
        )

        # Features basadas en la edad:
        if self.age_features:
            X_ = X_.assign(
                age = lambda x: round((x['trans_date_trans_time']-x['dob']).dt.days / 365.25,2)
            )

        # Features basadas en fecha y hora de la trx:
        def categorize_part_of_day(hour):
            if 5 <= hour < 12:
                return 'Morning'
            elif 12 <= hour < 17:
                return 'Afternoon'
            elif 17 <= hour < 21:
                return 'Evening'
            elif 21 <= hour or hour < 5:
                return 'Night'

        if self.timestamp_features:
            X_ = X_.assign(
                trans_date__year = lambda x: x["trans_date_trans_time"].dt.year,
                trans_date__month = lambda x: x["trans_date_trans_time"].dt.month,
                trans_date__day = lambda x: x["trans_date_trans_time"].dt.day,
                trans_date__dow = lambda x: x["trans_date_trans_time"].dt.dayofweek,
                trans_date__hour = lambda x: x["trans_date_trans_time"].dt.hour,
                trans_date__partofday = lambda x: x['trans_date__hour'].apply(categorize_part_of_day)
            )

        if self.distance_features:
            # Latitud y longitud en radianes
            lat1 = np.radians(X_['lat'])
            lon1 = np.radians(X_['long'])
            lat2 = np.radians(X_['merch_lat'])
            lon2 = np.radians(X_['merch_long'])
            
            # Fórmula Haversine para calcular la distancia:
            dlat = lat2 - lat1
            dlon = lon2 - lon1
            a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
            c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
            R = 6371  # Radio de la tierra (en kilometros)
            X_['distance_to_merch'] = round(R * c, 3) # Distancia (en kilometros)

        X_ = X_.drop(['trans_date_trans_time','dob'], axis=1) 
        return X_
.fit_transform()
transformaciones = TransformacionesIniciales()
transformaciones.fit(X_train)
X_test_transformed = transformaciones.transform(X_test)
merchant category amt city_pop job zip lat long merch_lat merch_long age trans_date__year trans_date__month trans_date__day trans_date__dow trans_date__hour trans_date__partofday distance_to_merch
Barton Inc grocery_pos $78.80 333497.0 Mechanical engineer 29209 33.97 -80.94 34.58 -81.19 51.24 2019 6 15 5 0 Night 71.93
Schroeder, Wolff and Hermiston travel $3.83 9051.0 Audiological scientist 43076 39.9 -82.41 39.18 -82.87 36.68 2019 9 15 6 21 Night 89.63
Hahn, Douglas and Schowalter travel $428.09 467.0 Psychologist, forensic 40914 37.1 -83.57 36.51 -84.47 35.42 2019 11 4 0 19 Evening 103.36
Kuhn LLC misc_net $36.16 139650.0 Database administrator 32935 28.14 -80.65 27.53 -79.72 62.18 2019 4 1 0 9 Morning 114.32

3.4 Feature engineering

Preprocesamiento
preproc_categoricas = Pipeline(steps=[
    ('rare_labels', RareCategoryGrouper(min_freq=0.01)),
    ('imputar_nulos', SimpleImputer(missing_values=np.nan, strategy='most_frequent')),
    ('mean_encoder', MeanEncoder())
])

preproc_numericas = Pipeline(steps=[
    ('imputar_nulos', SimpleImputer(strategy='median'))
])

preproc = ColumnTransformer([
    ('cat', preproc_categoricas, make_column_selector(dtype_exclude=['float','int'])),
    ('num', preproc_numericas, make_column_selector(dtype_include=['float','int']))
], verbose_feature_names_out=False, remainder='drop', verbose=True)

from sklearn.pipeline import FeatureUnion
features_preprocessing = Pipeline([
    ('feature_eng', TransformacionesIniciales()),
    ('preprocessing', preproc),
    ('anomalies', FeatureUnion([
            ('anomaly', IsolationForestTransformer()),
            ('outliers', OutlierRemover())
        ])
    )
], verbose=True)

4 Modelo

4.1 Pipeline de modelado

Preprocesamiento
clf = CatBoostClassifier(
    iterations=500,
    depth=6,
    learning_rate=0.1,
    loss_function="Logloss",
    eval_metric="Recall",
    class_weights=[1, 20],
    random_seed=42,
    verbose=100,
    
)

pipe = Pipeline([
    ('preproc', features_preprocessing),
    ('model', clf)   
])

4.2 Entrenamiento del pipeline completo

Durante el entrenamiento del pipeline (preprocesamiento + modelo), se visualizan los tiempos que tarda cada uno de los pasos:

pipe.fit(X_train, y_train)

[Pipeline] .............. (step 1 of 3) Processing init, total=   1.7s
[ColumnTransformer] ........... (1 of 2) Processing cat, total=   1.7s
[ColumnTransformer] ........... (2 of 2) Processing num, total=   2.0s
[Pipeline] ....... (step 2 of 3) Processing feature_eng, total=   3.9s
[Pipeline] .......... (step 3 of 3) Processing features, total=   6.3s
0:  learn: 0.0000000    total: 112ms    remaining: 55.6s
100:    learn: 0.2958851    total: 8.02s    remaining: 31.7s
200:    learn: 0.3090586    total: 15.8s    remaining: 23.5s
300:    learn: 0.3216400    total: 23.6s    remaining: 15.6s
400:    learn: 0.3303730    total: 32s  remaining: 7.9s
499:    learn: 0.3396980    total: 39.9s    remaining: 0us

5 Predicciones

5.1 Predicciones sobre datos nuevos

Almacenar el modelo. Cargar el archivo .pkl para utilizarlo:

pickle.dump(): Guardar el modelo
with open('pipe_model_fraud.pkl', 'wb') as file:
    pickle.dump(pipe, file)
pickle.load(): Cargar el modelo
with open('pipe_model_fraud.pkl', 'rb') as file:
    pipe = pickle.load(file)

🆕 Datos de una nueva transacción:

Nueva transacción
nueva_trx = pd.DataFrame({
    "trans_date_trans_time": "2019-10-09 20:38:49",
    "merchant": np.nan,
    "category": "gas_transport",
    "amt": 9.66,
    "city_pop": 10000,
    "job": np.nan,
    "dob": "1995-08-16",
    "zip": np.nan,
    "lat": 45.8433,
    "long": -113.1948,
    "merch_lat": 45.837213,
    "merch_long": -113.191425,
}, index=["nueva_trx"])
trans_date_trans_time merchant category amt city_pop job dob zip lat long merch_lat merch_long
2019-10-09 20:38:49 gas_transport 9.66 10000 1995-08-16 45.8433 -113.1948 45.837213 -113.191425

Utilizar el modelo para estimar la probabilidad de que la nueva transacción sea fraudulenta:

Predicción de probabilidad
y_pred = pipe.predict_proba(nueva_trx)[:,1]

La probabilidad de que la transacción sea fraudulenta es: 5.90%

6 Comentarios finales

6.1 Comentarios finales

  • El foco de esta presentación está puesto sobre el flujo de procesamiento de datos.

  • Todas las transformaciones presentadas son a modo ilustrativo, otras transformaciones podrían generar mejores resultados.

  • Siempre se debe aplicar las transformaciones luego de particionar el dataset en train/test, para evitar “fuga de datos”.

6.2 Referencias / Recursos

6.3 Contacto

karinabartolome

karbartolome

karbartolome

Blog